Micron Document
🎖️GitЯра🎖️

Commit 39139398e64c7aa2b781386885344c3333320ca6


Parents : 6c32af9
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-28T16:12:28-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-28T21:12:28Z

fix(model): rssi explicit presence for protobufs 2.7.26.138 (#6498)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

Changes

31 files changed, 2003 insertions(+), 42 deletions(-)


Diff

diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt
index 731e54fa2f..1b7877251c 100644
--- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt
+++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt
@@ -36,6 +36,7 @@ import org.meshtastic.app.map.addScaleBarOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.rememberMapViewWithLifecycle
import org.meshtastic.app.map.zoomIn
+import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.ui.theme.DiscoveryMapColors
import org.meshtastic.core.ui.util.DiscoveryMapNode
import org.meshtastic.core.ui.util.DiscoveryNeighborType
@@ -121,7 +122,7 @@ fun DiscoveryOsmMap(
position = nodeGeoPoint
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
title = node.longName ?: node.shortName ?: "Unknown"
- snippet = "SNR: ${node.snr} dB / RSSI: ${node.rssi} dBm"
+ snippet = "SNR: ${node.snr} dB / RSSI: ${MetricFormatter.rssi(node.rssi)}"
val drawableId =
if (node.isSensorNode) {

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt
index 492fc84d3b..3af7ebd510 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt
@@ -36,6 +36,7 @@ import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
+import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Person
import org.meshtastic.core.ui.icon.Temperature
@@ -125,7 +126,7 @@ fun DiscoveryGoogleMap(
MarkerComposable(
state = rememberUpdatedMarkerState(position = nodeLatLng),
title = node.longName ?: node.shortName ?: "Unknown",
- snippet = "SNR: ${node.snr} dB / RSSI: ${node.rssi} dBm",
+ snippet = "SNR: ${node.snr} dB / RSSI: ${MetricFormatter.rssi(node.rssi)}",
) {
DiscoveryMarkerChip(label = node.shortName ?: "?", color = markerColor, icon = nodeIcon)
}

diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt
index d2944acfeb..a989094ec3 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt
@@ -47,7 +47,11 @@ object MetricFormatter {
fun snr(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)} dB"
- fun rssi(value: Int): String = "$value dBm"
+ /**
+ * Formats a received signal strength, or [UNKNOWN_VALUE] when the radio reported none. 0 dBm is a legitimate
+ * reading on some radios, so it must never stand in for a missing one.
+ */
+ fun rssi(value: Int?): String = if (value == null) UNKNOWN_VALUE else "$value dBm"
fun windSpeed(metersPerSecond: Float, isImperial: Boolean, decimalPlaces: Int = 1): String {
val value = if (isImperial) metersPerSecond * MPH_PER_MPS else metersPerSecond
@@ -62,6 +66,9 @@ object MetricFormatter {
}
}
+/** Shown in place of a metric the radio did not report. A symbol, so it needs no translation. */
+private const val UNKNOWN_VALUE = "—"
+
private const val FAHRENHEIT_SCALE = 1.8f
private const val FAHRENHEIT_OFFSET = 32
private const val MPH_PER_MPS = 2.23694f

diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
index fe23d914bb..fe6989e5b4 100644
--- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
@@ -116,6 +116,11 @@ class MetricFormatterTest {
assertEquals("0 dBm", MetricFormatter.rssi(0))
}
+ @Test
+ fun rssiAbsentIsNotRenderedAsZero() {
+ assertEquals("—", MetricFormatter.rssi(null))
+ }
+
@Test
fun snrNegative() {
assertEquals("-5.5 dB", MetricFormatter.snr(-5.5f))

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
index 05692b5ab6..0015b9b420 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
@@ -321,7 +321,8 @@ class MeshMessageProcessorImpl(
viaMqtt = viaMqtt,
lastTransport = packet.transport_mechanism.value,
snr = if (updateRadioMetrics) packet.rx_snr else node.snr,
- rssi = if (updateRadioMetrics) packet.rx_rssi else node.rssi,
+ // A packet carrying no rssi must not clobber the node's last real reading.
+ rssi = if (updateRadioMetrics) packet.rx_rssi ?: node.rssi else node.rssi,
hopsAway = hopsAway,
)
}

diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/51.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/51.json
new file mode 100644
index 0000000000..41b0e9aec7
--- /dev/null
+++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/51.json
@@ -0,0 +1,1750 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 51,
+ "identityHash": "fac493f83f8c535c8dc04c05a36f33fa",
+ "entities": [
+ {
+ "tableName": "my_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "model",
+ "columnName": "model",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "firmwareVersion",
+ "columnName": "firmwareVersion",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "couldUpdate",
+ "columnName": "couldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shouldUpdate",
+ "columnName": "shouldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentPacketId",
+ "columnName": "currentPacketId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageTimeoutMsec",
+ "columnName": "messageTimeoutMsec",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minAppVersion",
+ "columnName": "minAppVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxChannels",
+ "columnName": "maxChannels",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasWifi",
+ "columnName": "hasWifi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceId",
+ "columnName": "deviceId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pioEnv",
+ "columnName": "pioEnv",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum"
+ ]
+ }
+ },
+ {
+ "tableName": "nodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `air_quality_metrics` BLOB NOT NULL DEFAULT x'', `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `power_channel_labels` TEXT NOT NULL DEFAULT '[]', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, `has_xeddsa_signed` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "user",
+ "columnName": "user",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastHeard",
+ "columnName": "last_heard",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceTelemetry",
+ "columnName": "device_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viaMqtt",
+ "columnName": "via_mqtt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hops_away",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "is_favorite",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isIgnored",
+ "columnName": "is_ignored",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isMuted",
+ "columnName": "is_muted",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "environmentTelemetry",
+ "columnName": "environment_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "powerTelemetry",
+ "columnName": "power_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "airQualityTelemetry",
+ "columnName": "air_quality_metrics",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ },
+ {
+ "fieldPath": "paxcounter",
+ "columnName": "paxcounter",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "public_key",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "powerChannelLabels",
+ "columnName": "power_channel_labels",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "manuallyVerified",
+ "columnName": "manually_verified",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "nodeStatus",
+ "columnName": "node_status",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastTransport",
+ "columnName": "last_transport",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "signsPackets",
+ "columnName": "has_xeddsa_signed",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_nodes_last_heard",
+ "unique": false,
+ "columnNames": [
+ "last_heard"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)"
+ },
+ {
+ "name": "index_nodes_short_name",
+ "unique": false,
+ "columnNames": [
+ "short_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)"
+ },
+ {
+ "name": "index_nodes_long_name",
+ "unique": false,
+ "columnNames": [
+ "long_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)"
+ },
+ {
+ "name": "index_nodes_hops_away",
+ "unique": false,
+ "columnNames": [
+ "hops_away"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)"
+ },
+ {
+ "name": "index_nodes_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)"
+ },
+ {
+ "name": "index_nodes_last_heard_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "last_heard",
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)"
+ },
+ {
+ "name": "index_nodes_public_key",
+ "unique": false,
+ "columnNames": [
+ "public_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0, `message_text` TEXT NOT NULL DEFAULT '', `translated_text` TEXT, `show_translated` INTEGER NOT NULL DEFAULT 0)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "port_num",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_time",
+ "columnName": "received_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "read",
+ "columnName": "read",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "data",
+ "columnName": "data",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "filtered",
+ "columnName": "filtered",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageText",
+ "columnName": "message_text",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "translatedText",
+ "columnName": "translated_text",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "showTranslated",
+ "columnName": "show_translated",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_packet_myNodeNum",
+ "unique": false,
+ "columnNames": [
+ "myNodeNum"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)"
+ },
+ {
+ "name": "index_packet_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ },
+ {
+ "name": "index_packet_contact_key",
+ "unique": false,
+ "columnNames": [
+ "contact_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)"
+ },
+ {
+ "name": "index_packet_contact_key_port_num_received_time",
+ "unique": false,
+ "columnNames": [
+ "contact_key",
+ "port_num",
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)"
+ },
+ {
+ "name": "index_packet_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ },
+ {
+ "name": "index_packet_received_time",
+ "unique": false,
+ "columnNames": [
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)"
+ },
+ {
+ "name": "index_packet_filtered",
+ "unique": false,
+ "columnNames": [
+ "filtered"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)"
+ },
+ {
+ "name": "index_packet_read",
+ "unique": false,
+ "columnNames": [
+ "read"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet_fts",
+ "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`message_text`, tokenize=`unicode61`, content=`packet`)",
+ "fields": [
+ {
+ "fieldPath": "messageText",
+ "columnName": "message_text",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": []
+ },
+ "ftsVersion": "FTS5",
+ "ftsOptions": {
+ "tokenizer": "unicode61",
+ "tokenizerArgs": [],
+ "contentTable": "packet",
+ "languageIdColumnName": "",
+ "matchInfo": "FTS4",
+ "notIndexedColumns": [],
+ "prefixSizes": [],
+ "preferredOrder": "ASC",
+ "contentRowId": "",
+ "columnSize": true,
+ "detail": "FULL"
+ },
+ "contentSyncTriggers": [
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_UPDATE BEFORE UPDATE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_DELETE BEFORE DELETE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_UPDATE AFTER UPDATE ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_INSERT AFTER INSERT ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END"
+ ]
+ },
+ {
+ "tableName": "contact_settings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))",
+ "fields": [
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "muteUntil",
+ "columnName": "muteUntil",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadMessageUuid",
+ "columnName": "last_read_message_uuid",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastReadMessageTimestamp",
+ "columnName": "last_read_message_timestamp",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "filteringDisabled",
+ "columnName": "filtering_disabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "contact_key"
+ ]
+ }
+ },
+ {
+ "tableName": "log",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message_type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_date",
+ "columnName": "received_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "raw_message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromNum",
+ "columnName": "from_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "portNum",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "fromRadio",
+ "columnName": "from_radio",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_log_from_num",
+ "unique": false,
+ "columnNames": [
+ "from_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)"
+ },
+ {
+ "name": "index_log_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "quick_chat",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mode",
+ "columnName": "mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ }
+ },
+ {
+ "tableName": "reactions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "replyId",
+ "columnName": "reply_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "emoji",
+ "columnName": "emoji",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relays",
+ "columnName": "relays",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relayNode",
+ "columnName": "relay_node",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "to",
+ "columnName": "to",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum",
+ "reply_id",
+ "user_id",
+ "emoji"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_reactions_reply_id",
+ "unique": false,
+ "columnNames": [
+ "reply_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)"
+ },
+ {
+ "name": "index_reactions_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "metadata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "proto",
+ "columnName": "proto",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_metadata_num",
+ "unique": false,
+ "columnNames": [
+ "num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "device_hardware",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))",
+ "fields": [
+ {
+ "fieldPath": "activelySupported",
+ "columnName": "actively_supported",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "architecture",
+ "columnName": "architecture",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasInkHud",
+ "columnName": "has_ink_hud",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hasMui",
+ "columnName": "has_mui",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hwModel",
+ "columnName": "hwModel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hwModelSlug",
+ "columnName": "hw_model_slug",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "partitionScheme",
+ "columnName": "partition_scheme",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "platformioTarget",
+ "columnName": "platformio_target",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requiresDfu",
+ "columnName": "requires_dfu",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "supportLevel",
+ "columnName": "support_level",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "platformio_target"
+ ]
+ }
+ },
+ {
+ "tableName": "device_link",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`short_code` TEXT NOT NULL, `link_description` TEXT, `is_vendor` INTEGER NOT NULL, `regions` TEXT, `targets` TEXT, PRIMARY KEY(`short_code`))",
+ "fields": [
+ {
+ "fieldPath": "shortCode",
+ "columnName": "short_code",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "linkDescription",
+ "columnName": "link_description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isVendor",
+ "columnName": "is_vendor",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "regions",
+ "columnName": "regions",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "targets",
+ "columnName": "targets",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "short_code"
+ ]
+ }
+ },
+ {
+ "tableName": "firmware_release",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pageUrl",
+ "columnName": "page_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseNotes",
+ "columnName": "release_notes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "zipUrl",
+ "columnName": "zip_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseType",
+ "columnName": "release_type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "traceroute_node_position",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "logUuid",
+ "columnName": "log_uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestId",
+ "columnName": "request_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "log_uuid",
+ "node_num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_traceroute_node_position_log_uuid",
+ "unique": false,
+ "columnNames": [
+ "log_uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)"
+ },
+ {
+ "name": "index_traceroute_node_position_request_id",
+ "unique": false,
+ "columnNames": [
+ "request_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "log",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "log_uuid"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "discovery_session",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `presets_scanned` TEXT NOT NULL, `home_preset` TEXT NOT NULL, `total_unique_nodes` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `total_messages` INTEGER NOT NULL DEFAULT 0, `total_sensor_packets` INTEGER NOT NULL DEFAULT 0, `furthest_node_distance` REAL NOT NULL DEFAULT 0.0, `completion_status` TEXT NOT NULL DEFAULT 'complete', `ai_summary` TEXT, `user_latitude` REAL NOT NULL DEFAULT 0.0, `user_longitude` REAL NOT NULL DEFAULT 0.0, `total_dwell_seconds` INTEGER NOT NULL DEFAULT 0, `device_address` TEXT, `home_lora_config` BLOB, `home_primary_channel` BLOB)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetsScanned",
+ "columnName": "presets_scanned",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "homePreset",
+ "columnName": "home_preset",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalUniqueNodes",
+ "columnName": "total_unique_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "avgChannelUtilization",
+ "columnName": "avg_channel_utilization",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "totalMessages",
+ "columnName": "total_messages",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "totalSensorPackets",
+ "columnName": "total_sensor_packets",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "furthestNodeDistance",
+ "columnName": "furthest_node_distance",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "completionStatus",
+ "columnName": "completion_status",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'complete'"
+ },
+ {
+ "fieldPath": "aiSummary",
+ "columnName": "ai_summary",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "userLatitude",
+ "columnName": "user_latitude",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "userLongitude",
+ "columnName": "user_longitude",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "totalDwellSeconds",
+ "columnName": "total_dwell_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "deviceAddress",
+ "columnName": "device_address",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "homeLoraConfig",
+ "columnName": "home_lora_config",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "homePrimaryChannel",
+ "columnName": "home_primary_channel",
+ "affinity": "BLOB"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "discovery_preset_result",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `session_id` INTEGER NOT NULL, `preset_name` TEXT NOT NULL, `dwell_duration_seconds` INTEGER NOT NULL DEFAULT 0, `unique_nodes` INTEGER NOT NULL DEFAULT 0, `direct_neighbor_count` INTEGER NOT NULL DEFAULT 0, `mesh_neighbor_count` INTEGER NOT NULL DEFAULT 0, `infrastructure_node_count` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `avg_airtime_rate` REAL NOT NULL DEFAULT 0.0, `packet_success_rate` REAL NOT NULL DEFAULT 0.0, `packet_failure_rate` REAL NOT NULL DEFAULT 0.0, `ai_summary` TEXT, `num_packets_tx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx_bad` INTEGER NOT NULL DEFAULT 0, `num_rx_dupe` INTEGER NOT NULL DEFAULT 0, `num_tx_relay` INTEGER NOT NULL DEFAULT 0, `num_tx_relay_canceled` INTEGER NOT NULL DEFAULT 0, `num_online_nodes` INTEGER NOT NULL DEFAULT 0, `num_total_nodes` INTEGER NOT NULL DEFAULT 0, `uptime_seconds` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`session_id`) REFERENCES `discovery_session`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sessionId",
+ "columnName": "session_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetName",
+ "columnName": "preset_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dwellDurationSeconds",
+ "columnName": "dwell_duration_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "uniqueNodes",
+ "columnName": "unique_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "directNeighborCount",
+ "columnName": "direct_neighbor_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "meshNeighborCount",
+ "columnName": "mesh_neighbor_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "infrastructureNodeCount",
+ "columnName": "infrastructure_node_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageCount",
+ "columnName": "message_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sensorPacketCount",
+ "columnName": "sensor_packet_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "avgChannelUtilization",
+ "columnName": "avg_channel_utilization",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "avgAirtimeRate",
+ "columnName": "avg_airtime_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "packetSuccessRate",
+ "columnName": "packet_success_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "packetFailureRate",
+ "columnName": "packet_failure_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "aiSummary",
+ "columnName": "ai_summary",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "numPacketsTx",
+ "columnName": "num_packets_tx",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numPacketsRx",
+ "columnName": "num_packets_rx",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numPacketsRxBad",
+ "columnName": "num_packets_rx_bad",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numRxDupe",
+ "columnName": "num_rx_dupe",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTxRelay",
+ "columnName": "num_tx_relay",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTxRelayCanceled",
+ "columnName": "num_tx_relay_canceled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numOnlineNodes",
+ "columnName": "num_online_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTotalNodes",
+ "columnName": "num_total_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "uptimeSeconds",
+ "columnName": "uptime_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_discovery_preset_result_session_id",
+ "unique": false,
+ "columnNames": [
+ "session_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovery_preset_result_session_id` ON `${TABLE_NAME}` (`session_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "discovery_session",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "session_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "discovered_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `preset_result_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `short_name` TEXT, `long_name` TEXT, `neighbor_type` TEXT NOT NULL DEFAULT 'direct', `latitude` REAL, `longitude` REAL, `distance_from_user` REAL, `hop_count` INTEGER NOT NULL DEFAULT 0, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `is_infrastructure` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`preset_result_id`) REFERENCES `discovery_preset_result`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetResultId",
+ "columnName": "preset_result_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "neighborType",
+ "columnName": "neighbor_type",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'direct'"
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "distanceFromUser",
+ "columnName": "distance_from_user",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "hopCount",
+ "columnName": "hop_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "messageCount",
+ "columnName": "message_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sensorPacketCount",
+ "columnName": "sensor_packet_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isInfrastructure",
+ "columnName": "is_infrastructure",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_discovered_node_preset_result_id",
+ "unique": false,
+ "columnNames": [
+ "preset_result_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_preset_result_id` ON `${TABLE_NAME}` (`preset_result_id`)"
+ },
+ {
+ "name": "index_discovered_node_node_num",
+ "unique": false,
+ "columnNames": [
+ "node_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_node_num` ON `${TABLE_NAME}` (`node_num`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "discovery_preset_result",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "preset_result_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "event_firmware_edition",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`edition` TEXT NOT NULL, `display_name` TEXT NOT NULL, `welcome_message` TEXT NOT NULL, `event_start` TEXT, `event_end` TEXT, `time_zone` TEXT, `location` TEXT, `icon_url` TEXT, `accent_color` TEXT, `tag` TEXT, `domain` TEXT, `theme_json` TEXT, `firmware_json` TEXT, `links_json` TEXT NOT NULL, PRIMARY KEY(`edition`))",
+ "fields": [
+ {
+ "fieldPath": "edition",
+ "columnName": "edition",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "welcomeMessage",
+ "columnName": "welcome_message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "eventStart",
+ "columnName": "event_start",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "eventEnd",
+ "columnName": "event_end",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "timeZone",
+ "columnName": "time_zone",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "location",
+ "columnName": "location",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "iconUrl",
+ "columnName": "icon_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "accentColor",
+ "columnName": "accent_color",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "tag",
+ "columnName": "tag",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "domain",
+ "columnName": "domain",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "themeJson",
+ "columnName": "theme_json",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "firmwareJson",
+ "columnName": "firmware_json",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "linksJson",
+ "columnName": "links_json",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "edition"
+ ]
+ }
+ },
+ {
+ "tableName": "merge_marker",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source_db_name` TEXT NOT NULL, `merged_at` INTEGER NOT NULL, PRIMARY KEY(`source_db_name`))",
+ "fields": [
+ {
+ "fieldPath": "sourceDbName",
+ "columnName": "source_db_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mergedAt",
+ "columnName": "merged_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "source_db_name"
+ ]
+ }
+ },
+ {
+ "tableName": "channel_set",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `channel_set` BLOB NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channelSet",
+ "columnName": "channel_set",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fac493f83f8c535c8dc04c05a36f33fa')"
+ ]
+ }
+}
\ No newline at end of file

diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/DiscoveryMigrationTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/DiscoveryMigrationTest.kt
index d39fa41ef9..01217d9e09 100644
--- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/DiscoveryMigrationTest.kt
+++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/DiscoveryMigrationTest.kt
@@ -191,7 +191,7 @@ class DiscoveryMigrationTest {
assertNull(loaded.distanceFromUser)
assertEquals(0, loaded.hopCount)
assertEquals(0f, loaded.snr)
- assertEquals(0, loaded.rssi)
+ assertNull(loaded.rssi)
assertEquals(0, loaded.messageCount)
assertEquals(0, loaded.sensorPacketCount)
}

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
index 9c7d60ad6b..705ebcb6d0 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
@@ -129,8 +129,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 47, to = 48),
AutoMigration(from = 48, to = 49),
AutoMigration(from = 49, to = 50),
+ AutoMigration(from = 50, to = 51),
],
- version = 50,
+ version = 51,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveredNodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveredNodeEntity.kt
index eeb8c7eb36..6a439f6bf6 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveredNodeEntity.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveredNodeEntity.kt
@@ -47,7 +47,8 @@ data class DiscoveredNodeEntity(
@ColumnInfo(name = "distance_from_user") val distanceFromUser: Double? = null,
@ColumnInfo(name = "hop_count", defaultValue = "0") val hopCount: Int = 0,
@ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f,
- @ColumnInfo(name = "rssi", defaultValue = "0") val rssi: Int = 0,
+ /** Null when no packet from this node reported an rssi. Rows written before schema 51 store 0 for both cases. */
+ @ColumnInfo(name = "rssi") val rssi: Int? = null,
@ColumnInfo(name = "message_count", defaultValue = "0") val messageCount: Int = 0,
@ColumnInfo(name = "sensor_packet_count", defaultValue = "0") val sensorPacketCount: Int = 0,
@ColumnInfo(name = "is_infrastructure", defaultValue = "0") val isInfrastructure: Boolean = false,

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
index 77550a51f0..99ab2c19d9 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
@@ -103,7 +103,8 @@ data class Packet(
@ColumnInfo(name = "packet_id", defaultValue = "0") val packetId: Int = 0,
@ColumnInfo(name = "routing_error", defaultValue = "-1") var routingError: Int = -1,
@ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f,
- @ColumnInfo(name = "rssi", defaultValue = "0") val rssi: Int = 0,
+ /** Null when the radio reported no rssi. Rows written before schema 51 store 0 for both absent and 0 dBm. */
+ @ColumnInfo(name = "rssi") val rssi: Int? = null,
@ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1,
@ColumnInfo(name = "sfpp_hash") val sfpp_hash: ByteString? = null,
@ColumnInfo(name = "filtered", defaultValue = "0") val filtered: Boolean = false,
@@ -162,7 +163,8 @@ data class ReactionEntity(
val emoji: String,
val timestamp: Long,
@ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f,
- @ColumnInfo(name = "rssi", defaultValue = "0") val rssi: Int = 0,
+ /** Null when the radio reported no rssi. Rows written before schema 51 store 0 for both absent and 0 dBm. */
+ @ColumnInfo(name = "rssi") val rssi: Int? = null,
@ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1,
@ColumnInfo(name = "packet_id", defaultValue = "0") val packetId: Int = 0,
@ColumnInfo(name = "status", defaultValue = "0") val status: MessageStatus = MessageStatus.UNKNOWN,

diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketDaoAtomicTransactionTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketDaoAtomicTransactionTest.kt
index 7180fe8100..a2202594e5 100644
--- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketDaoAtomicTransactionTest.kt
+++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/PacketDaoAtomicTransactionTest.kt
@@ -158,6 +158,18 @@ class PacketDaoAtomicTransactionTest {
// ── updateLastReadMessage ────────────────────────────────────────────────────
+ @Test
+ fun rssiRoundTripsAbsentAndZeroDistinctly() = runTest {
+ seedMyNodeInfo()
+ val contact = "0${NodeAddress.ID_BROADCAST}"
+ packetDao.insert(textPacket(contact, "no reading", time = 1000L).copy(rssi = null))
+ packetDao.insert(textPacket(contact, "zero dBm", time = 2000L).copy(rssi = 0))
+
+ // Order-independent: the query returns newest-first, which is not what this test is about.
+ val stored = packetDao.getMessagesFrom(contact).first().map { it.packet.rssi }.toSet()
+ assertEquals(setOf(null, 0), stored, "0 dBm is a real reading and must not collapse into absent")
+ }
+
@Test
fun updateLastReadMessageCreatesSettingsWhenAbsent() = runTest {
seedMyNodeInfo()

diff --git a/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt b/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt
index d6d6ece1b8..b5bd8d60f7 100644
--- a/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt
+++ b/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt
@@ -17,12 +17,15 @@
package org.meshtastic.core.database
import androidx.room3.testing.MigrationTestHelper
+import androidx.sqlite.SQLiteConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
+import androidx.sqlite.execSQL
import kotlinx.coroutines.test.runTest
import java.io.File
import kotlin.io.path.Path
import kotlin.test.AfterTest
import kotlin.test.Test
+import kotlin.test.assertEquals
/**
* Creates the earliest exported schema (v3) and walks every auto-migration up to the current version, validating the
@@ -64,6 +67,57 @@ class MeshtasticDatabaseMigrationTest {
helper.runMigrationsAndValidate(latestSchemaVersion(), emptyList()).close()
}
+ /**
+ * 50→51 makes the three `rssi` columns nullable, which Room implements by recreating `packet`, `reactions` and
+ * `discovered_node` (DROP + RENAME). [migrateAll] only proves the resulting schema validates from an empty
+ * database; this proves existing rows survive the rebuild with their values — including a legacy `rssi = 0`, which
+ * stays 0 rather than becoming NULL.
+ */
+ @Test
+ fun rssiColumnsGoNullableWithoutLosingRows() = runTest {
+ helper.createDatabase(RSSI_NULLABLE_FROM_VERSION).use { connection ->
+ connection.execSQL(
+ "INSERT INTO packet (uuid, myNodeNum, port_num, contact_key, received_time, read, data, snr, rssi) " +
+ "VALUES (1, 42, 1, '0^all', 1000, 1, '{}', 5.0, 0)",
+ )
+ connection.execSQL(
+ "INSERT INTO reactions (myNodeNum, reply_id, user_id, emoji, timestamp, snr, rssi) " +
+ "VALUES (42, 7, '!abc', 'X', 2000, 5.0, -70)",
+ )
+ connection.execSQL(
+ "INSERT INTO discovery_session (id, timestamp, presets_scanned, home_preset) " +
+ "VALUES (1, 3000, 1, 'LONG_FAST')",
+ )
+ connection.execSQL(
+ "INSERT INTO discovery_preset_result (id, session_id, preset_name) VALUES (1, 1, 'LONG_FAST')",
+ )
+ connection.execSQL(
+ "INSERT INTO discovered_node (id, preset_result_id, node_num, snr, rssi) VALUES (1, 1, 99, 5.0, 0)",
+ )
+ }
+
+ helper.runMigrationsAndValidate(RSSI_NULLABLE_TO_VERSION, emptyList()).use { connection ->
+ assertEquals(listOf("0"), queryColumn(connection, "SELECT rssi FROM packet"))
+ assertEquals(listOf("-70"), queryColumn(connection, "SELECT rssi FROM reactions"))
+ assertEquals(listOf("0"), queryColumn(connection, "SELECT rssi FROM discovered_node"))
+ // The recreate must not have orphaned the cascading FK target.
+ assertEquals(listOf("1"), queryColumn(connection, "SELECT preset_result_id FROM discovered_node"))
+ // A NULL is now storable where the column was previously NOT NULL DEFAULT 0.
+ connection.execSQL("UPDATE packet SET rssi = NULL WHERE uuid = 1")
+ assertEquals(listOf(null), queryColumn(connection, "SELECT rssi FROM packet"))
+ }
+ }
+
+ /** Reads one column of every row as a string, with SQL NULL surfaced as Kotlin null. */
+ private fun queryColumn(connection: SQLiteConnection, sql: String): List<String?> =
+ connection.prepare(sql).use { statement ->
+ buildList {
+ while (statement.step()) {
+ add(if (statement.isNull(0)) null else statement.getText(0))
+ }
+ }
+ }
+
private fun latestSchemaVersion(): Int {
val dbSchemas = schemaDir.resolve(checkNotNull(MeshtasticDatabase::class.qualifiedName)).toFile()
return dbSchemas
@@ -74,5 +128,7 @@ class MeshtasticDatabaseMigrationTest {
private companion object {
const val EARLIEST_SCHEMA_VERSION = 3
+ const val RSSI_NULLABLE_FROM_VERSION = 50
+ const val RSSI_NULLABLE_TO_VERSION = 51
}
}

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt
index 9b59476708..cc0fe001a8 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt
@@ -52,7 +52,8 @@ data class DataPacket(
var wantAck: Boolean = true, // If true, the receiver should send an ack back
var hopStart: Int = 0,
var snr: Float = 0f,
- var rssi: Int = 0,
+ /** Received signal strength, or null when the radio did not report one. 0 dBm is a valid reading. */
+ var rssi: Int? = null,
var replyId: Int? = null, // If this is a reply to a previous message, this is the ID of that message
var relayNode: Int? = null,
var relays: Int = 0,

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt
index 6ae7046fff..0390b3c882 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt
@@ -28,9 +28,9 @@ import org.meshtastic.proto.MeshBeacon
* @param fromNodeNum The node that broadcast the beacon (informational only — beacons are unsigned).
* @param beacon The decoded advertisement, carrying the display [message][MeshBeacon.message] and the join offer.
* @param snr Signal-to-noise ratio of the received beacon packet, in dB (0 when unknown).
- * @param rssi Received signal strength of the beacon packet, in dBm (0 when unknown).
+ * @param rssi Received signal strength of the beacon packet, in dBm, or null when the radio reported none.
*/
-data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon, val snr: Float = 0f, val rssi: Int = 0) {
+data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon, val snr: Float = 0f, val rssi: Int? = null) {
/** Stable identity for dedup/dismiss: a given sender advertising a given channel is one standing invitation. */
val key: String
get() = "$fromNodeNum:${beacon.offer_channel?.name.orEmpty()}"
@@ -55,8 +55,9 @@ data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon, val snr
/**
* Inverse of [encode]; returns null for a structurally malformed record (wrong field count, unparseable node
- * number, or an undecodable beacon payload). An unparseable snr/rssi falls back to 0 — they are non-critical
- * display metrics, not identity, so a bad numeric there does not discard an otherwise-valid invitation.
+ * number, or an undecodable beacon payload). An unparseable snr falls back to 0 and an unparseable rssi to
+ * absent — they are non-critical display metrics, not identity, so a bad numeric there does not discard an
+ * otherwise-valid invitation. An absent rssi encodes as `null`, which [String.toIntOrNull] round-trips back.
*/
@Suppress("ReturnCount")
fun decode(record: String): MeshBeaconOffer? {
@@ -65,7 +66,7 @@ data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon, val snr
val node = parts[0].toIntOrNull() ?: return null
val beaconBytes = parts.last().decodeBase64()?.toByteArray() ?: return null
val beacon = runCatching { MeshBeacon.ADAPTER.decode(beaconBytes) }.getOrNull() ?: return null
- return MeshBeaconOffer(node, beacon, parts[1].toFloatOrNull() ?: 0f, parts[2].toIntOrNull() ?: 0)
+ return MeshBeaconOffer(node, beacon, parts[1].toFloatOrNull() ?: 0f, parts[2].toIntOrNull())
}
}
}

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt
index 6d00828ad1..253eb3a67d 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt
@@ -160,7 +160,8 @@ data class Message(
val packetId: Int,
val emojis: List<Reaction>,
val snr: Float,
- val rssi: Int,
+ /** Received signal strength, or null when the radio did not report one. 0 dBm is a valid reading. */
+ val rssi: Int?,
val hopsAway: Int,
val replyId: Int?,
val originalMessage: Message? = null,

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt
index 1431c47b60..6b336b2e09 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt
@@ -25,7 +25,8 @@ data class Reaction(
val emoji: String,
val timestamp: Long,
val snr: Float,
- val rssi: Int,
+ /** Received signal strength, or null when the radio did not report one (locally sent reactions included). */
+ val rssi: Int?,
val hopsAway: Int,
val packetId: Int = 0,
val status: MessageStatus = MessageStatus.UNKNOWN,

diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshBeaconOfferTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshBeaconOfferTest.kt
index 39176f4a93..70936442a7 100644
--- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshBeaconOfferTest.kt
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshBeaconOfferTest.kt
@@ -193,6 +193,20 @@ class MeshBeaconOfferTest {
assertEquals(offer, restored)
}
+ @Test
+ fun `encode then decode round-trips an absent rssi without collapsing it to zero`() {
+ val offer =
+ MeshBeaconOffer(
+ fromNodeNum = 42,
+ beacon = MeshBeacon(message = "Join us", offer_channel = ChannelSettings(name = "PartyNet")),
+ snr = 6.5f,
+ rssi = null,
+ )
+ val restored = MeshBeaconOffer.decode(offer.encode())
+ assertEquals(offer, restored)
+ assertNull(restored?.rssi)
+ }
+
@Test
fun `decode returns null for a malformed record`() {
assertNull(MeshBeaconOffer.decode("not-a-valid-record"))

diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshDataMapperTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshDataMapperTest.kt
index 9fe3ab2fac..b051242f15 100644
--- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshDataMapperTest.kt
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/MeshDataMapperTest.kt
@@ -90,6 +90,26 @@ class MeshDataMapperTest {
assertEquals(MeshPacket.TransportMechanism.TRANSPORT_MQTT.value, mapped.transportMechanism)
}
+ @Test
+ fun toDataPacket_preservesAbsentRssiRatherThanCoercingToZero() {
+ val packet = MeshPacket(from = 1, to = 2, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
+
+ val mapped = mapper.toDataPacket(packet)
+
+ assertNotNull(mapped)
+ assertNull(mapped.rssi)
+ }
+
+ @Test
+ fun toDataPacket_keepsAReportedZeroRssiDistinctFromAbsent() {
+ val packet = MeshPacket(from = 1, to = 2, rx_rssi = 0, decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP))
+
+ val mapped = mapper.toDataPacket(packet)
+
+ assertNotNull(mapped)
+ assertEquals(0, mapped.rssi)
+ }
+
@Test
fun toDataPacket_usesPkcChannelWhenPacketIsPkiEncrypted() {
val packet =

diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt
index 6871668703..d6fcb69efb 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt
@@ -89,7 +89,8 @@ internal class MessagingControllerImpl(
emoji = emoji,
timestamp = nowMillis,
snr = 0f,
- rssi = 0,
+ // Our own reaction was never received over the air, so it has no rssi reading.
+ rssi = null,
hopsAway = 0,
packetId = dataPacket.id,
status = MessageStatus.QUEUED,

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
index f2a0bee987..d01bbb9a48 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt
@@ -96,7 +96,7 @@ enum class Quality(
@Composable
fun NodeSignalQuality(
snr: Float,
- rssi: Int,
+ rssi: Int?,
modifier: Modifier = Modifier,
modemPreset: ModemPreset? = LocalModemPreset.current,
) {
@@ -126,7 +126,7 @@ private const val SIZE_ICON_DP = 16
/** Displays the `snr` and `rssi` with color depending on the values respectively. */
@Composable
-fun SnrAndRssi(snr: Float, rssi: Int, modemPreset: ModemPreset? = LocalModemPreset.current) {
+fun SnrAndRssi(snr: Float, rssi: Int?, modemPreset: ModemPreset? = LocalModemPreset.current) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Snr(snr, modemPreset = modemPreset)
Rssi(rssi)
@@ -173,8 +173,10 @@ fun Snr(snr: Float, modifier: Modifier = Modifier, modemPreset: ModemPreset? = L
)
}
+/** Renders nothing when [rssi] is absent — 0 dBm is a real reading, so it must not stand in for "no reading". */
@Composable
-fun Rssi(rssi: Int, modifier: Modifier = Modifier, label: String = stringResource(Res.string.rssi)) {
+fun Rssi(rssi: Int?, modifier: Modifier = Modifier, label: String = stringResource(Res.string.rssi)) {
+ if (rssi == null) return
val color: Color =
if (rssi > RSSI_GOOD_THRESHOLD) {
Quality.GOOD.color.invoke()

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/DiscoveryMapNode.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/DiscoveryMapNode.kt
index e1b5352b0d..34efae175d 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/DiscoveryMapNode.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/DiscoveryMapNode.kt
@@ -33,7 +33,8 @@ data class DiscoveryMapNode(
val longName: String?,
val neighborType: DiscoveryNeighborType,
val snr: Float = 0f,
- val rssi: Int = 0,
+ /** Null when no packet from this node reported an rssi. */
+ val rssi: Int? = null,
val messageCount: Int = 0,
val sensorPacketCount: Int = 0,
) {

diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt
index f826904a0d..4869d2df26 100644
--- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt
@@ -136,7 +136,8 @@ class DiscoveryScanEngine(
var latitude: Double? = null,
var longitude: Double? = null,
var snr: Float = 0f,
- var rssi: Int = 0,
+ /** Null until a packet reports one, so an absent reading stays distinct from a valid 0 dBm. */
+ var rssi: Int? = null,
var hopCount: Int = 0,
var messageCount: Int = 0,
var sensorPacketCount: Int = 0,
@@ -267,7 +268,8 @@ class DiscoveryScanEngine(
val node = collectedNodes.getOrPut(fromNum) { CollectedNodeData(nodeNum = fromNum) }
// Update signal info from the direct packet
if (meshPacket.rx_snr != 0f) node.snr = meshPacket.rx_snr
- if (meshPacket.rx_rssi != 0) node.rssi = meshPacket.rx_rssi
+ // Explicit presence: record a reported 0 dBm, skip only a genuinely absent one.
+ meshPacket.rx_rssi?.let { node.rssi = it }
node.hopCount = dataPacket.hopsAway.coerceAtLeast(0)
when (portNum) {
@@ -488,7 +490,7 @@ class DiscoveryScanEngine(
val node =
collectedNodes.getOrPut(neighborNum) { CollectedNodeData(nodeNum = neighborNum, neighborType = "mesh") }
// Only mark as mesh if not already seen directly
- if (node.snr == 0f && node.rssi == 0) {
+ if (node.snr == 0f && node.rssi == null) {
node.neighborType = "mesh"
}
}

diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt
index 826ebaa6f9..e301850d97 100644
--- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt
@@ -17,6 +17,7 @@
package org.meshtastic.feature.discovery.export
import org.meshtastic.core.common.util.DateFormatter
+import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.database.entity.DiscoveredNodeEntity
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
@@ -58,7 +59,7 @@ internal object DiscoveryReportFormatter {
append(node.longName ?: node.shortName ?: "!${node.nodeNum.toString(radix = 16)}")
append(" | ${node.neighborType}")
append(" | SNR: ${NumberFormatter.format(node.snr, 1)}")
- append(" | RSSI: ${node.rssi}")
+ append(" | RSSI: ${MetricFormatter.rssi(node.rssi)}")
val distance = node.distanceFromUser
if (distance != null) {
append(" | ${NumberFormatter.format(distance, 0)}m")

diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/scan/DiscoveryRankingEngine.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/scan/DiscoveryRankingEngine.kt
index 974f908331..7f1791f163 100644
--- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/scan/DiscoveryRankingEngine.kt
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/scan/DiscoveryRankingEngine.kt
@@ -36,8 +36,11 @@ data class RankingScoreBreakdown(
val nonDupePacketCount: Int,
/** Criterion 4a: median SNR across discovered nodes. */
val medianSnr: Float,
- /** Criterion 4b: median RSSI across discovered nodes (tiebreak within criterion 4). */
- val medianRssi: Int,
+ /**
+ * Criterion 4b: median RSSI across discovered nodes (tiebreak within criterion 4). Null when no node reported one —
+ * 0 dBm would otherwise read as an excellent median and outrank real negative readings.
+ */
+ val medianRssi: Int?,
/** Criterion 5: best known distance to a valid-position node (metres). */
val bestKnownDistance: Double,
/** Criterion 6: failure/reconnect penalty (packet failure rate). */
@@ -93,7 +96,8 @@ class DiscoveryRankingEngine {
val nodes = discoveredNodes
val snrValues = nodes.map { it.snr }.sorted()
- val rssiValues = nodes.map { it.rssi }.sorted()
+ // Nodes that never reported an rssi are excluded rather than dragged toward 0 dBm.
+ val rssiValues = nodes.mapNotNull { it.rssi }.sorted()
return ScoredPreset(
presetResult = pr,
@@ -161,7 +165,9 @@ class DiscoveryRankingEngine {
// 4. Best median link quality: SNR first, then RSSI
cmp = b.breakdown.medianSnr.compareTo(a.breakdown.medianSnr)
if (cmp != 0) return@Comparator cmp
- cmp = b.breakdown.medianRssi.compareTo(a.breakdown.medianRssi)
+ // Higher rssi wins, but a preset where nobody reported one ranks after any measured preset rather
+ // than winning on a phantom 0 dBm.
+ cmp = compareByRssiDescendingMissingLast(a.breakdown.medianRssi, b.breakdown.medianRssi)
if (cmp != 0) return@Comparator cmp
// 5. Greatest best-known distance
@@ -183,9 +189,17 @@ class DiscoveryRankingEngine {
}
}
- /** Compute the median of a sorted Int list. Returns 0 for empty. */
- private fun medianInt(sorted: List<Int>): Int {
- if (sorted.isEmpty()) return 0
+ /** Orders two medians best-first, with an absent median always losing to a measured one. */
+ private fun compareByRssiDescendingMissingLast(a: Int?, b: Int?): Int = when {
+ a == b -> 0
+ a == null -> 1
+ b == null -> -1
+ else -> b.compareTo(a)
+ }
+
+ /** Compute the median of a sorted Int list. Returns null for empty — 0 is a real rssi, not "no data". */
+ private fun medianInt(sorted: List<Int>): Int? {
+ if (sorted.isEmpty()) return null
val mid = sorted.size / 2
return if (sorted.size % 2 == 0) {
(sorted[mid - 1] + sorted[mid]) / 2

diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt
index f593e5a8e1..330c6c53ff 100644
--- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt
+++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt
@@ -99,7 +99,7 @@ internal fun MeshBeaconInvitationCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
- if (offer.rssi != 0 || offer.snr != 0f) {
+ if (offer.rssi != null || offer.snr != 0f) {
Text(
text =
stringResource(

diff --git a/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryRankingEngineTest.kt b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryRankingEngineTest.kt
index 63737a2e74..70831e60f1 100644
--- a/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryRankingEngineTest.kt
+++ b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryRankingEngineTest.kt
@@ -25,6 +25,7 @@ import org.meshtastic.feature.discovery.scan.PresetRankingInput
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
+import kotlin.test.assertNull
import kotlin.test.assertTrue
class DiscoveryRankingEngineTest {
@@ -59,7 +60,7 @@ class DiscoveryRankingEngineTest {
presetResultId: Long = 1,
nodeNum: Long = 1,
snr: Float = 0f,
- rssi: Int = 0,
+ rssi: Int? = 0,
distanceFromUser: Double? = null,
) = DiscoveredNodeEntity(
presetResultId = presetResultId,
@@ -373,10 +374,24 @@ class DiscoveryRankingEngineTest {
val result = engine.rank(listOf(input(p, emptyList())))
assertEquals(0f, result[0].scoreBreakdown.medianSnr)
- assertEquals(0, result[0].scoreBreakdown.medianRssi)
+ assertNull(result[0].scoreBreakdown.medianRssi, "no nodes means no rssi median, not 0 dBm")
assertEquals(0.0, result[0].scoreBreakdown.bestKnownDistance)
}
+ @Test
+ fun presetWithNoRssiReadingsDoesNotOutrankMeasuredPreset() {
+ val pA = preset(id = 1, name = "measured", uniqueNodes = 3, numPacketsRx = 50)
+ val pB = preset(id = 2, sessionId = 100, name = "unreported", uniqueNodes = 3, numPacketsRx = 50)
+ // Equal on every earlier criterion; B reports no rssi at all, so its median is absent rather than 0 dBm.
+ val nodesA = List(3) { node(presetResultId = 1, nodeNum = it + 1L, snr = 5f, rssi = -60) }
+ val nodesB = List(3) { node(presetResultId = 2, nodeNum = it + 4L, snr = 5f, rssi = null) }
+
+ val result = engine.rank(listOf(input(pB, nodesB), input(pA, nodesA)))
+
+ assertEquals("measured", result[0].presetResult.presetName, "an absent rssi median must not win on 0 dBm")
+ assertNull(result[1].scoreBreakdown.medianRssi)
+ }
+
@Test
fun nodesWithoutDistanceYieldZeroBestDistance() {
val p = preset(id = 1, uniqueNodes = 2)

diff --git a/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngineTest.kt b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngineTest.kt
index d85a9aa4de..f0be3acee9 100644
--- a/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngineTest.kt
+++ b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngineTest.kt
@@ -439,6 +439,53 @@ class DiscoveryScanEngineTest {
assertEquals(-70, node.rssi)
}
+ @Test
+ fun reportedZeroRssiIsRecordedRatherThanDiscarded() = runTest {
+ val engine = createEngine(this)
+ nodeRepository.setMyNodeInfo(createMyNodeInfo())
+
+ engine.startScan(testPresets, dwellDurationSeconds = 60)
+ assertScanActive(engine)
+
+ while (engine.scanState.value !is DiscoveryScanState.Dwell) {
+ delay(100)
+ }
+
+ // 0 dBm is a legitimate reading on some radios, not a stand-in for "no reading".
+ val meshPacket =
+ createPositionMeshPacket(from = 12345, latI = 377749300, lonI = -1224194200, snr = 5.5f, rssi = 0)
+ engine.onPacketReceived(meshPacket, createDataPacket(from = 12345))
+ engine.stopScan()
+
+ assertEquals(0, discoveryDao.discoveredNodes.values.first().rssi)
+ }
+
+ @Test
+ fun absentRssiPersistsAsNullRatherThanZero() = runTest {
+ val engine = createEngine(this)
+ nodeRepository.setMyNodeInfo(createMyNodeInfo())
+
+ engine.startScan(testPresets, dwellDurationSeconds = 60)
+ assertScanActive(engine)
+
+ while (engine.scanState.value !is DiscoveryScanState.Dwell) {
+ delay(100)
+ }
+
+ val posPayload = Position.ADAPTER.encode(Position(latitude_i = 377749300)).toByteString()
+ val meshPacket =
+ MeshPacket(
+ from = 12345,
+ decoded = Data(portnum = PortNum.POSITION_APP, payload = posPayload),
+ rx_snr = 5.5f,
+ rx_rssi = null,
+ )
+ engine.onPacketReceived(meshPacket, createDataPacket(from = 12345))
+ engine.stopScan()
+
+ assertNull(discoveryDao.discoveredNodes.values.first().rssi)
+ }
+
@Test
fun telemetryWithLocalStatsPopulatesRfHealth() = runTest {
val engine = createEngine(this)

diff --git a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt
index 21732f7d00..681dcfd738 100644
--- a/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt
+++ b/feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/component/Reaction.kt
@@ -276,7 +276,9 @@ internal fun ReactionDialog(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
- val isLocalOrPreDbUpdateReaction = (reaction.rssi == 0)
+ // Local reactions now carry a null rssi; pre-schema-51 rows stored 0, so a legacy
+ // 0 dBm reading stays indistinguishable from "no reading" and remains hidden.
+ val isLocalOrPreDbUpdateReaction = reaction.rssi == null || reaction.rssi == 0
if (!isLocalOrPreDbUpdateReaction) {
if (reaction.hopsAway == 0) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
index 9a5d2152e0..27d0853597 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
@@ -454,7 +454,8 @@ open class MetricsViewModel(
rows = data,
epochSeconds = { it.rx_time.toLong() },
) { p ->
- "\"${p.rx_rssi}\",\"${p.rx_snr}\""
+ // An absent rssi exports as an empty field, matching the other optional metrics above.
+ "\"${p.rx_rssi ?: ""}\",\"${p.rx_snr}\""
}
}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
index 16729b6840..482c1cd144 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt
@@ -156,7 +156,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit, m
val localStatsData = state.localStats.filter { it.time.toLong() >= threshold && it.local_stats != null }
val data = remember(signalData, localStatsData) { buildSignalLog(signalData, localStatsData) }
val hasNoiseFloor = remember(localStatsData) { localStatsData.any { it.local_stats?.noise_floor != 0 } }
- val hasRssi = remember(signalData) { signalData.any { it.rx_rssi != 0 } }
+ val hasRssi = remember(signalData) { signalData.any { it.rx_rssi != null } }
val hasSnr = remember(signalData) { signalData.any { !it.rx_snr.isNaN() } }
val hasAnyLocalStats = state.localStats.isNotEmpty()
val localStatsExportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveLocalStatsCSV(uri, localStatsData) }
@@ -311,7 +311,7 @@ private fun SignalMetricsChart(
remember(noiseFloorData) {
if (noiseFloorData.size > 1) listOf(noiseFloorData.first(), noiseFloorData.last()) else emptyList()
}
- val rssiData = remember(meshPackets) { meshPackets.filter { it.rx_rssi != 0 } }
+ val rssiData = remember(meshPackets) { meshPackets.filter { it.rx_rssi != null } }
val snrData = remember(meshPackets) { meshPackets.filter { !it.rx_snr.isNaN() } }
val legendData =
remember(noiseFloorData, rssiData, snrData) {
@@ -353,7 +353,7 @@ private fun SignalMetricsChart(
lineModel { series(x = busyFloorData.map { it.time }, y = busyFloorData.map { BUSY_FLOOR_DBM }) }
}
if (rssiData.isNotEmpty()) {
- lineModel { series(x = rssiData.map { it.rx_time }, y = rssiData.map { it.rx_rssi }) }
+ lineModel { series(x = rssiData.map { it.rx_time }, y = rssiData.mapNotNull { it.rx_rssi }) }
}
if (snrData.isNotEmpty()) {
/* Use a separate lineModel call to associate SNR with the right axis. */

diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 9d551b8b22..089a1c2b56 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -99,7 +99,7 @@ mqttastic = "0.7.0"
jmdns = "3.6.3"
qrcode-kotlin = "4.5.0"
takpacket-sdk = "0.8.1"
-meshtastic-protobufs = "2.7.26.130-g642aae4-SNAPSHOT"
+meshtastic-protobufs = "2.7.26.138-g26db1b5-SNAPSHOT"
# Gradle Plugins
develocity = "4.5.0"

Served by rngit 1.5.2 - Generated in 0.5s